<?php
/**
 * Perform miscellaneous operations for Dashboard.
 *
 * @copyright 2009-2019 Vanilla Forums Inc.
 * @license GPL-2.0-only
 * @package Dashboard
 * @since 2.0
 */

use Garden\Web\Exception\ServerException;
use Vanilla\Formatting\DateTimeFormatter;
use Vanilla\Site\OwnSite;
use Vanilla\Utility\Timers;

/**
 * Handles /utility endpoint.
 */
class UtilityController extends DashboardController
{
    /** A flag used to indicate the site was put into maintenance mode in an automated fashion. */
    const MAINTENANCE_AUTO = 2;

    /** @var array Models to automatically instantiate. */
    public $Uses = ["Form"];

    /** @var  Gdn_Form $Form */
    public $Form;

    /**
     * @var array Special-case HTTP headers that are otherwise unidentifiable as HTTP headers.
     * Typically, HTTP headers in the $_SERVER array will be prefixed with
     * `HTTP_` or `X_`. These are not so we list them here for later reference.
     */
    protected static $specialHeaders = [
        "CONTENT_TYPE",
        "CONTENT_LENGTH",
        "PHP_AUTH_USER",
        "PHP_AUTH_PW",
        "PHP_AUTH_DIGEST",
        "AUTH_TYPE",
    ];

    /**
     * UtilityController constructor.
     */
    public function __construct()
    {
        parent::__construct();
        $this->setHeader(self::HEADER_CACHE_CONTROL, self::NO_CACHE);
    }

    /**
     * Runs before every call to this controller.
     */
    public function initialize()
    {
        parent::initialize();
        set_time_limit(0); // Is this even doing anything?
    }

    /**
     * Set the sort order for data on an arbitrary database table.
     *
     * Expect post values TransientKey, Target (redirect URL), Table (database table name),
     * and TableID (an array of sort order => unique ID).
     *
     * @since 2.0.0
     * @access public
     */
    public function sort()
    {
        $this->permission("Garden.Settings.Manage");

        if (Gdn::request()->isAuthenticatedPostBack()) {
            $tableID = Gdn::request()->post("TableID");
            if ($tableID) {
                if (!preg_match('/^[A-Za-z0-9_]+$/', $tableID)) {
                    throw new InvalidArgumentException("Invalid TableID.");
                }
                $rows = Gdn::request()->post($tableID);
                if (is_array($rows)) {
                    $table = str_replace(["Table", "`"], "", $tableID);
                    $modelName = $table . "Model";
                    if (class_exists($modelName)) {
                        $tableModel = new $modelName();
                    } else {
                        $tableModel = new Gdn_Model($table);
                    }

                    foreach ($rows as $sort => $iD) {
                        if (strpos($iD, "_") !== false) {
                            [, $iD] = explode("_", $iD, 2);
                        }
                        if (!$iD) {
                            continue;
                        }

                        $tableModel->setField($iD, "Sort", $sort);
                    }
                    $this->setData("Result", true);
                }
            }
        }

        $this->render("Blank");
    }

    /**
     * Allows the setting of data into one of two serialized data columns on the
     * user table: Preferences and Attributes.
     *
     * The method expects "Name" & "Value" to be in the $_POST collection. This method always
     * saves to the row of the user id performing this action (ie. $session->UserID). The
     * type of property column being saved should be specified in the url:
     * i.e. /dashboard/utility/set/preference/name/value/transientKey
     * or /dashboard/utility/set/attribute/name/value/transientKey
     *
     * @param string $userPropertyColumn The type of value being saved: preference or attribute.
     * @param string $name The name of the property being saved.
     * @param string $value The value of the property being saved.
     * @param string $transientKey A unique transient key to authenticate that the user intended to perform this action.
     * @since 2.0.0
     * @access public
     */
    public function set($userPropertyColumn = "", $name = "", $value = "", $transientKey = "")
    {
        deprecated("set", "", "February 2017");

        $whiteList = [];

        if (c("Garden.Profile.ShowActivities", true)) {
            $whiteList = array_merge($whiteList, [
                "Email.WallComment",
                "Email.ActivityComment",
                "Popup.WallComment",
                "Popup.ActivityComment",
            ]);
        }

        $this->_DeliveryType = DELIVERY_TYPE_BOOL;
        $session = Gdn::session();
        $success = false;

        // Get index of whitelisted name
        $index = array_search(strtolower($name), array_map("strtolower", $whiteList));

        if (!empty($whiteList) && $index !== false) {
            // Force name to have casing present in whitelist
            $name = $whiteList[$index];

            // Force value
            if ($value != "1") {
                $value = "0";
            }

            if (
                in_array($userPropertyColumn, ["preference", "attribute"]) &&
                $name != "" &&
                $value != "" &&
                $session->UserID > 0 &&
                $session->validateTransientKey($transientKey)
            ) {
                $userModel = Gdn::factory("UserModel");
                $method = $userPropertyColumn == "preference" ? "SavePreference" : "SaveAttribute";
                $success = $userModel->$method($session->UserID, $name, $value) ? "TRUE" : "FALSE";
            }
        }

        if (!$success) {
            $this->Form->addError("ErrorBool");
        }

        // Redirect back where the user came from if necessary
        if ($this->_DeliveryType == DELIVERY_TYPE_ALL) {
            redirectTo($_SERVER["HTTP_REFERER"]);
        } else {
            $this->render();
        }
    }

    public function sprites()
    {
        $this->removeCssFile("admin.css");
        $this->addCssFile("style.css");
        $this->addCssFile("vanillicon.css", "static");
        $this->MasterView = "default";

        $this->CssClass = "SplashMessage NoPanel";
        $this->setData("_NoMessages", true);
        $this->setData("Title", "Sprite Sheet");
        $this->render();
    }

    /**
     * Update database structure based on current definitions in each app's structure.php file.
     */
    public function structure()
    {
        $this->permission("Garden.Settings.Manage");

        Gdn_Theme::section("VanillaStaff");

        if (!$this->Form->authenticatedPostBack()) {
            // The form requires a postback to do anything.
            $step = "start";
        } else {
            $scan = $this->Form->getFormValue("Scan");
            $run = $this->Form->getFormValue("Run");
            $step = "start";
            if (!empty($scan)) {
                $step = "scan";
            } elseif (!empty($run)) {
                $step = "run";
            }
        }

        switch ($step) {
            case "scan":
                $this->runStructure(true);
                break;
            case "run":
                $this->runStructure(false);
                break;
            case "start":
            default:
            // Nothing to do here.
        }

        $this->setData("Step", $step);
        $this->setHighlightRoute("dashboard/settings/configure");
        $this->addCssFile("admin.css");
        $this->setData("Title", t("Database Structure Upgrades"));
        $this->render();
    }

    /**
     * Run the database structure or /utility/structure.
     *
     * Note: Keep this method private!
     *
     * @param bool $captureOnly Whether to list changes rather than execute.
     * @throws Exception Throws an exception if there was an error in the structure process.
     */
    private function runStructure($captureOnly = true)
    {
        // This permission is run again to be sure someone doesn't accidentally call this method incorrectly.
        $this->permission("Garden.Settings.Manage");
        Timers::instance()->setShouldRecordProfile(true);

        $updateModel = \Gdn::getContainer()->get(UpdateModel::class);
        $capturedSql = $updateModel->runStructure($captureOnly);
        $this->setData("CapturedSql", $capturedSql);

        $issues = Gdn::structure()->getIssues();
        if ($this->Form->errorCount() == 0 && !$captureOnly) {
            if (empty($issues)) {
                $this->setData("Status", "The structure was successfully executed.");
            } else {
                $this->setData("Status", "The structure completed with issues.");
            }
        }
        $this->setData("Issues", $issues);
    }

    /**
     * Legacy version of update.
     */
    private function legacyUpdate()
    {
        // Check for permission or flood control.
        // These settings are loaded/saved to the database because we don't want the config file storing non/config information.
        $now = time();
        $lastTime = 0;
        $count = 0;
        try {
            $lastTime = Gdn::get("Garden.Update.LastTimestamp", 0);
        } catch (Exception $ex) {
            // We don't have a GDN_UserMeta table yet. Sit quietly and one will appear.
        }
        if ($lastTime + 60 * 60 * 24 > $now) {
            // Check for flood control.
            try {
                $count = Gdn::get("Garden.Update.Count", 0) + 1;
            } catch (Exception $ex) {
                // Once more we sit, watching the breath.
            }
            if ($count > 5) {
                if (!Gdn::session()->checkPermission("Garden.Settings.Manage")) {
                    // We are only allowing an update of 5 times every 24 hours.
                    throw permissionException();
                }
            }
        } else {
            $count = 1;
        }
        try {
            Gdn::set("Garden.Update.LastTimestamp", $now);
            Gdn::set("Garden.Update.Count", $count);
        } catch (Exception $ex) {
            // What is a GDN_UserMeta table, really? Suffering.
        }
        try {
            // Run the structure.
            $updateModel = \Gdn::getContainer()->get(UpdateModel::class);
            $updateModel->runStructure();
            $this->setData("Success", true);
        } catch (Exception $ex) {
            $this->statusCode(500);
            $this->setData("Success", false);
            $this->setData("Error", $ex->getMessage());
            if (debug()) {
                throw \Garden\Web\Exception\HttpException::createFromThrowable($ex);
            }
        }
        if (Gdn::session()->checkPermission("Garden.Settings.Manage")) {
            saveToConfig("Garden.Version", APPLICATION_VERSION);
        }
        if ($target = $this->Request->get("Target")) {
            redirectTo($target);
        }
        $this->fireEvent("AfterUpdate");
        if ($this->deliveryType() === DELIVERY_TYPE_DATA) {
            // Make sure that we do not disclose anything too sensitive here!
            $this->Data = array_filter(
                $this->Data,
                function ($key) {
                    return in_array(strtolower($key), ["success", "error"]);
                },
                ARRAY_FILTER_USE_KEY
            );
        }
        $this->MasterView = "empty";
        $this->CssClass = "Home";
        Gdn_Theme::section("Utility");
        $this->render("update-legacy", "utility", "dashboard");
    }

    /**
     * Run a structure update on the database.
     *
     * @since 2.0.?
     * @access public
     */
    public function update()
    {
        if (\Vanilla\FeatureFlagHelper::featureEnabled("updateTokens")) {
            $this->updateWithToken();
        } else {
            $this->legacyUpdate();
        }
    }

    /**
     * Run a structure update on the database.
     *
     * It should always be possible to call this method, even if no database tables exist yet.
     * A working forum database should be built from scratch where none exists. Therefore,
     * it can have no reliance on existing data calls, or they must be able to fail gracefully.
     *
     * @since 2.0.?
     * @access public
     */
    public function updateWithToken()
    {
        $this->ApplicationFolder = "dashboard";
        $this->MasterView = "setup";
        $this->setData("Success", null);

        // Do some checks for backwards for behavior for CD.
        if ($this->Request->isPostBack()) {
            $success = $this->doUpdate();
            $this->setData("Success", $success);
        }

        if ($this->deliveryType() === DELIVERY_TYPE_DATA) {
            // Make sure that we do not disclose anything too sensitive here!
            $this->Data = array_filter(
                $this->Data,
                function ($key) {
                    return in_array(strtolower($key), ["success", "error"]);
                },
                ARRAY_FILTER_USE_KEY
            );
        }

        $this->removeCssFile("admin.css");
        $this->addCssFile("setup.css");
        $this->addJsFile("jquery.js");
        Gdn_Theme::section("Utility");

        if ($this->deliveryType() === DELIVERY_TYPE_ALL) {
            $this->setData("_isAdmin", Gdn::session()->checkPermission("Garden.Settings.Manage"));
            $this->setData("form", $this->Form);
            $this->setData("headerImage", asset("/applications/dashboard/design/images/vanilla_logo.png"));
        }
        $this->render($this->View, "utility", "dashboard");
    }

    /**
     * Signs of life.
     *
     * @since 2.0.?
     * @access public
     */
    public function alive()
    {
        $this->setData("Success", true);
        $this->MasterView = "empty";
        $this->CssClass = "Home";

        $ownSite = \Gdn::getContainer()->get(OwnSite::class);
        $this->setData("Site", [
            "siteID" => $ownSite->getSiteID(),
            "accountID" => $ownSite->getAccountID(),
            "clusterID" => $ownSite->getClusterID(),
            "regionID" => $ownSite->getCluster()->getRegionID(),
        ]);

        $this->fireEvent("Alive");
        Gdn_Theme::section("Utility");
        $this->render();
    }

    /**
     *
     *
     * @throws Exception
     */
    public function ping()
    {
        $start = microtime(true);

        $this->setData("pong", true);
        $this->MasterView = "empty";
        $this->CssClass = "Home";

        $valid = true;

        // Test the cache.
        if (Gdn::cache()->activeEnabled()) {
            $k = betterRandomString(20);
            Gdn::cache()->store($k, 1);
            Gdn::cache()->increment($k, 1);
            $v = Gdn::cache()->get($k);

            if ($v !== 2) {
                $valid = false;
                $this->setData("cache", false);
            } else {
                $this->setData("cache", true);
            }
        } else {
            $this->setData("cache", "disabled");
        }

        // Test the db.
        try {
            $users = Gdn::sql()->get("User", "UserID", "asc", 1);
            $this->setData("database", true);
        } catch (Exception $ex) {
            $this->setData("database", false);
            $valid = false;
        }

        $this->EventArguments["Valid"] = &$valid;
        $this->fireEvent("Ping");

        if (!$valid) {
            $this->statusCode(500);
        }

        $time = microtime(true) - $start;
        $this->setData("time", DateTimeFormatter::timeStampToTime($time));
        $this->setData("time_s", $time);
        $this->setData("valid", $valid);
        $this->title("Ping");
        Gdn_Theme::section("Utility");

        $this->render();
    }

    /**
     * Set the user's timezone (hour offset).
     *
     * @param string $ClientDate Client-reported datetime.
     * @param string $transientKey Security token.
     * @since 2.0.0
     * @access public
     */
    public function setClientHour($clientHour = "", $transientKey = "")
    {
        $this->_DeliveryType = DELIVERY_TYPE_BOOL;
        $success = false;

        if (is_numeric($clientHour) && $clientHour >= 0 && $clientHour < 24) {
            $hourOffset = $clientHour - date("G", time());

            if (Gdn::session()->isValid() && Gdn::session()->validateTransientKey($transientKey)) {
                Gdn::userModel()->setField(Gdn::session()->UserID, "HourOffset", $hourOffset);
                $success = true;
            }
        }

        $this->render();
    }

    /**
     *
     *
     * @throws Exception
     */
    public function setHourOffset()
    {
        $form = new Gdn_Form();

        if ($form->authenticatedPostBack()) {
            if (!Gdn::session()->isValid()) {
                throw permissionException("Garden.SignIn.Allow");
            }

            $hourOffset = $form->getFormValue("HourOffset");
            Gdn::userModel()->setField(Gdn::session()->UserID, "HourOffset", $hourOffset);

            // If we receive a time zone, only accept it if we can verify it as a valid identifier.
            $timeZone = $form->getFormValue("TimeZone");
            if (!empty($timeZone)) {
                try {
                    $tz = new DateTimeZone($timeZone);
                    Gdn::userModel()->saveAttribute(Gdn::session()->UserID, [
                        "TimeZone" => $tz->getName(),
                        "SetTimeZone" => null,
                    ]);
                } catch (\Exception $ex) {
                    Logger::log(Logger::ERROR, $ex->getMessage(), ["timeZone" => $timeZone]);

                    Gdn::userModel()->saveAttribute(Gdn::session()->UserID, [
                        "TimeZone" => null,
                        "SetTimeZone" => $timeZone,
                    ]);
                    $timeZone = "";
                }
            } elseif ($currentTimeZone = Gdn::session()->getAttribute("TimeZone")) {
                // Check to see if the current timezone agrees with the posted offset.
                try {
                    $tz = new DateTimeZone($currentTimeZone);
                    $currentHourOffset = $tz->getOffset(new DateTime()) / 3600;
                    if ($currentHourOffset != $hourOffset) {
                        // Clear out the current timezone or else it will override the browser's offset.
                        Gdn::userModel()->saveAttribute(Gdn::session()->UserID, [
                            "TimeZone" => null,
                            "SetTimeZone" => null,
                        ]);
                    } else {
                        $timeZone = $tz->getName();
                    }
                } catch (Exception $ex) {
                    Logger::log(Logger::ERROR, "Clearing out bad timezone: {timeZone}", [
                        "timeZone" => $currentTimeZone,
                    ]);
                    // Clear out the bad timezone.
                    Gdn::userModel()->saveAttribute(Gdn::session()->UserID, [
                        "TimeZone" => null,
                        "SetTimeZone" => null,
                    ]);
                }
            }

            $this->setData("Result", true);
            $this->setData("HourOffset", $hourOffset);
            $this->setData("TimeZone", $timeZone);

            $time = time();
            $this->setData("UTCDateTime", gmdate("r", $time));
            $this->setData("UserDateTime", gmdate("r", $time + $hourOffset * 3600));
        } else {
            throw forbiddenException("GET");
        }

        $this->render("Blank");
    }

    /**
     * Grab a feed from the mothership.
     *
     * @param string $Type Type of feed.
     * @param int $Length Number of items to get.
     * @param string $FeedFormat How we want it (valid formats are 'normal' or 'sexy'. OK, not really).
     * @since 2.0.?
     * @access public
     */
    public function getFeed($type = "news", $length = 5, $feedFormat = "normal")
    {
        $this->auditLogEnabled = false;
        $validTypes = ["releases", "help", "news", "cloud"];
        $validFormats = ["extended", "normal"];

        $length = is_numeric($length) && $length <= 50 ? $length : 5;

        if (!in_array($type, $validTypes)) {
            $type = "news";
        }

        if (!in_array($feedFormat, $validFormats)) {
            $feedFormat = "normal";
        }
        $url = "https://open.vanillaforums.com/vforg/home/getfeed/{$type}/{$length}/{$feedFormat}/?DeliveryType=VIEW";
        $request = new Garden\Http\HttpClient();
        $responseBody = $request->get($url)->getBody();
        $this->setData("data", $responseBody);
        $this->deliveryType(DELIVERY_TYPE_VIEW);
        $this->render("raw", "utility", "dashboard");
    }

    /**
     * Return some meta information about any page on the internet in JSON format.
     */
    public function fetchPageInfo($url = "")
    {
        $pageInfo = fetchPageInfo($url);

        if (!empty($pageInfo["Exception"])) {
            throw new Gdn_UserException($pageInfo["Exception"], 400);
        }

        $this->setData("PageInfo", $pageInfo);
        $this->MasterView = "default";
        $this->removeCssFile("admin.css");
        $this->addCssFile("style.css");
        $this->addCssFile("vanillicon.css", "static");

        $this->setData("_NoPanel", true);
        $this->render();
    }

    /**
     * Toggle whether or not the site is in maintenance mode.
     *
     * @param int $updateMode
     */
    public function maintenance($updateMode = 0)
    {
        if (!$this->Form->authenticatedPostBack(true)) {
            throw forbiddenException("GET");
        }
        $this->permission("Garden.Settings.Manage");
        $currentMode = c("Garden.UpdateMode");

        /**
         * If $updateMode is equal to self::MAINTENANCE_AUTO, it assumed this action was performed via an automated
         * process.  A bit flag is added to the current value, so the original setting is restored, once maintenance
         * mode is disabled through this endpoint.
         */
        if ($updateMode == self::MAINTENANCE_AUTO) {
            // Apply the is-auto flag to the current maintenance setting, so the original setting can be retrieved.
            $updateMode = $currentMode | $updateMode;
        } elseif ($updateMode == 0 && $currentMode & self::MAINTENANCE_AUTO) {
            // If the is-auto flag is set, restore the original UpdateMode value.
            $updateMode = $currentMode & ~self::MAINTENANCE_AUTO;
        } else {
            $updateMode = (bool) $updateMode;
        }

        // Save the new setting and output the result.
        saveToConfig("Garden.UpdateMode", $updateMode);
        $this->setData(["UpdateMode" => $updateMode]);
        $this->deliveryType(DELIVERY_TYPE_DATA);
        $this->deliveryMethod(DELIVERY_METHOD_JSON);
        $this->render();
    }

    /**
     * Redirect to touch icon.
     *
     * @since 1.0
     * @access public
     */
    public function showTouchIcon()
    {
        $icon = c("Garden.TouchIcon");

        if (!empty($icon)) {
            redirectTo(Gdn_Upload::url($icon), 302, false);
        } else {
            throw new Exception("Touch icon not found.", 404);
        }
    }

    /**
     * Do the actual update.
     */
    private function doUpdate(): bool
    {
        if (
            $this->Request->hasHeader("Authorization") &&
            preg_match("`Bearer\s+(.+)`i", $this->Request->getHeader("Authorization"), $m)
        ) {
            $token = $m[1];
        } elseif ($this->Request->post("updateToken", "")) {
            $token = $this->Request->post("updateToken");
        }
        $isTokenUpdate = false;
        if (!empty($token)) {
            $knownString = (string) Gdn::config()->get("Garden.UpdateToken", "");
            if (!empty($knownString) && hash_equals($knownString, $token)) {
                Gdn::session()->validateTransientKey(true);
                $isTokenUpdate = true;
            } else {
                $this->Form->addError("Invalid update token", "updateToken");
                trigger_error("utility/update invalid update token.", E_USER_WARNING);
                return false;
            }
        } else {
            if (!Gdn::session()->checkPermission("Garden.Settings.Manage")) {
                throw permissionException("Garden.Settings.Manage");
            }
            if (!$this->Request->isAuthenticatedPostBack(false)) {
                trigger_error("Invalid transient key on utility/update.", E_USER_WARNING);
                $this->Request->isAuthenticatedPostBack(true);
            }
        }

        // Run the structure.
        $updateModel = \Gdn::getContainer()->get(UpdateModel::class);
        try {
            if (isset($isTokenUpdate)) {
                $updateModel->setRunAsSystem(true);
            }

            $updateModel->runStructure();
            $this->setData("Success", true);
        } catch (Exception $ex) {
            $this->statusCode(500);
            $this->setData("Success", false);
            $this->setData("Error", $ex->getMessage());

            if (debug()) {
                throw \Garden\Web\Exception\HttpException::createFromThrowable($ex);
            }
            return false;
        }

        saveToConfig("Garden.Version", APPLICATION_VERSION);

        $this->fireEvent("AfterUpdate");

        return true;
    }

    /**
     * Recalculate all reaction data, including totals.
     *
     */
    public function recalculateReactions()
    {
        $this->permission("Garden.Settings.Manage");

        $this->form = new Gdn_Form();

        if ($this->form->authenticatedPostback()) {
            $reactionModel = new ReactionModel();
            $reactionModel->recalculateTotals();
            $this->setData("Recalculated", true);
        }

        $this->addSideMenu();
        $this->setData("Title", t("Recalculate Reactions"));
        $this->render("Recalculate", "reactions", "dashboard");
    }
}
